Skip to content

feat(browser): Detect redirects when emitting navigation spans #16324

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 6 commits into from
Jul 10, 2025

Conversation

mydea
Copy link
Member

@mydea mydea commented May 19, 2025

Closes #15286

This PR adds a new option to browserTracingIntegration, detectRedirects, which is enabled by default. If this is enabled, the integration will try to detect if a navigation is actually a redirect based on a simple heuristic, and in this case, will not end the ongoing pageload/navigation, but instead let it run and create a navigation.redirect zero-duration span instead.

An example trace for this would be: https://sentry-sdks.sentry.io/explore/discover/trace/95280de69dc844448d39de7458eab527/?dataset=transactions&eventId=8a1150fd1dc846e4ac8420ccf03ad0ee&field=title&field=project&field=user.display&field=timestamp&name=All%20Errors&project=4504956726345728&query=&queryDataset=transaction-like&sort=-timestamp&source=discover&statsPeriod=5m&timestamp=1747646096&yAxis=count%28%29
image

Where the respective index route that triggered this has this code:

setTimeout(() => {
  window.history.pushState({}, "", "/test-sub-page");
  fetch('https://example.com')
}, 100);

The used heuristic is:

  • If the ongoing pageload/navigation was started less than 300ms ago...
  • ... and no click has happened in this time...
  • ... then we consider the navigation a redirect

this limit was chosen somewhat arbitrarily, open for other suggestions too.

While this logic will not be 100% bullet proof, it should be reliable enough and likely better than what we have today. Users can opt-out of this logic via browserTracingIntegration({ detectRedirects: false }), if needed.

@mydea mydea requested review from bcoe, Lms24 and s1gr1d May 19, 2025 09:21
@mydea mydea self-assigned this May 19, 2025
Copy link
Contributor

github-actions bot commented May 19, 2025

size-limit report 📦

Path Size % Change Change
@sentry/browser 23.99 kB - -
@sentry/browser - with treeshaking flags 23.76 kB - -
@sentry/browser (incl. Tracing) 39.85 kB +0.6% +235 B 🔺
@sentry/browser (incl. Tracing, Replay) 78.06 kB +0.31% +238 B 🔺
@sentry/browser (incl. Tracing, Replay) - with treeshaking flags 71.09 kB +0.27% +187 B 🔺
@sentry/browser (incl. Tracing, Replay with Canvas) 82.77 kB +0.28% +225 B 🔺
@sentry/browser (incl. Tracing, Replay, Feedback) 94.99 kB +0.3% +277 B 🔺
@sentry/browser (incl. Feedback) 40.76 kB - -
@sentry/browser (incl. sendFeedback) 28.7 kB - -
@sentry/browser (incl. FeedbackAsync) 33.59 kB - -
@sentry/react 25.76 kB - -
@sentry/react (incl. Tracing) 41.85 kB +0.58% +239 B 🔺
@sentry/vue 28.37 kB - -
@sentry/vue (incl. Tracing) 41.66 kB +0.6% +246 B 🔺
@sentry/svelte 24.01 kB - -
CDN Bundle 25.5 kB - -
CDN Bundle (incl. Tracing) 39.82 kB +0.48% +187 B 🔺
CDN Bundle (incl. Tracing, Replay) 75.8 kB +0.25% +187 B 🔺
CDN Bundle (incl. Tracing, Replay, Feedback) 81.27 kB +0.24% +193 B 🔺
CDN Bundle - uncompressed 74.5 kB - -
CDN Bundle (incl. Tracing) - uncompressed 118.25 kB +0.41% +481 B 🔺
CDN Bundle (incl. Tracing, Replay) - uncompressed 232.55 kB +0.21% +481 B 🔺
CDN Bundle (incl. Tracing, Replay, Feedback) - uncompressed 245.38 kB +0.2% +481 B 🔺
@sentry/nextjs (client) 43.48 kB +0.52% +222 B 🔺
@sentry/sveltekit (client) 40.32 kB +0.59% +235 B 🔺
@sentry/node 161.84 kB - -
@sentry/node - without tracing 98.79 kB - -
@sentry/aws-serverless 124.61 kB - -

View base workflow run

Copy link

codecov bot commented May 19, 2025

❌ Unsupported file format

Upload processing failed due to unsupported file format. Please review the parser error message:

Error parsing JUnit XML in /home/runner/work/sentry-javascript/sentry-javascript/packages/solidstart/vitest.junit.xml at 18:17

Caused by:
    RuntimeError: Error parsing XML
    
    Caused by:
        0: ill-formed document: expected `</testsuites>`, but `</testsuite>` was found
        1: expected `</testsuites>`, but `</testsuite>` was found

For more help, visit our troubleshooting guide.

@mydea mydea force-pushed the fn/detect-pageload-redirects branch 2 times, most recently from ccbd697 to eb3c0bc Compare May 23, 2025 07:22
@mydea mydea marked this pull request as ready for review May 23, 2025 07:22
@mydea mydea force-pushed the fn/detect-pageload-redirects branch from eb3c0bc to cb8e92e Compare May 26, 2025 11:22
@Lms24 Lms24 requested a review from edwardgou-sentry June 5, 2025 18:25
Copy link
Contributor

@edwardgou-sentry edwardgou-sentry left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Makes sense to me! There aren't many product areas in performance that specifically rely on navigations so I think this should be fine (and I think we'd consider surfacing redirects in those areas a bug anyways).

@@ -371,6 +396,10 @@ export const browserTracingIntegration = ((_options: Partial<BrowserTracingOptio
startTrackingInteractions();
}

if (detectRedirects && optionalWindowDocument) {
addEventListener('click', () => (lastClickTimestamp = timestampInSeconds()), { capture: true, passive: true });
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

are there other events, such as key presses, that could indicate a user manually navigating?

Copy link
Member

@Lms24 Lms24 Jun 16, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes, keypress might also be a good candidate, agreed.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍 also looking at keypress

Copy link
Member

@Lms24 Lms24 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry for the late review, but LGTM! I think we probably need to widen the timespan a bit because 300ms feel a bit fast to me (thinking of the endless redirects I get when doing SSO or stuff like this). But maybe it's good enough for now. I'd say its something we adjust on a per-feedback basis.

@@ -371,6 +396,10 @@ export const browserTracingIntegration = ((_options: Partial<BrowserTracingOptio
startTrackingInteractions();
}

if (detectRedirects && optionalWindowDocument) {
addEventListener('click', () => (lastClickTimestamp = timestampInSeconds()), { capture: true, passive: true });
Copy link
Member

@Lms24 Lms24 Jun 16, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes, keypress might also be a good candidate, agreed.

@mydea mydea force-pushed the fn/detect-pageload-redirects branch from 19d02d3 to 67791e9 Compare June 17, 2025 10:27
@mydea mydea force-pushed the fn/detect-pageload-redirects branch from 67791e9 to e2018b5 Compare June 18, 2025 07:52
@mydea mydea force-pushed the fn/detect-pageload-redirects branch from e2018b5 to 9dec9c3 Compare July 7, 2025 14:42
cursor[bot]

This comment was marked as outdated.

@mydea mydea force-pushed the fn/detect-pageload-redirects branch from 9dec9c3 to da0cffe Compare July 10, 2025 07:12
Copy link

@cursor cursor bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Navigation URL Metadata Update Fails

The scope.setSDKProcessingMetadata is not updated for navigation spans if the URL is falsy or if the navigation is detected as a redirect. This prevents subsequent events from having the correct URL information on the scope. Additionally, the redirect detection logic uses inconsistent timestamp functions (timestampInSeconds vs dateTimestampInSeconds), which can lead to inaccurate timing comparisons.

packages/browser/src/tracing/browserTracingIntegration.ts#L469-L780

const interactionHandler = (): void => {
lastInteractionTimestamp = timestampInSeconds();
};
addEventListener('click', interactionHandler, { capture: true });
addEventListener('keydown', interactionHandler, { capture: true, passive: true });
}
function maybeEndActiveSpan(): void {
const activeSpan = getActiveIdleSpan(client);
if (activeSpan && !spanToJSON(activeSpan).timestamp) {
DEBUG_BUILD && logger.log(`[Tracing] Finishing current active span with op: ${spanToJSON(activeSpan).op}`);
// If there's an open active span, we need to finish it before creating an new one.
activeSpan.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_IDLE_SPAN_FINISH_REASON, 'cancelled');
activeSpan.end();
}
}
client.on('startNavigationSpan', (startSpanOptions, navigationOptions) => {
if (getClient() !== client) {
return;
}
if (navigationOptions?.isRedirect) {
DEBUG_BUILD &&
logger.warn('[Tracing] Detected redirect, navigation span will not be the root span, but a child span.');
_createRouteSpan(
client,
{
op: 'navigation.redirect',
...startSpanOptions,
},
false,
);
return;
}
maybeEndActiveSpan();
getIsolationScope().setPropagationContext({ traceId: generateTraceId(), sampleRand: Math.random() });
const scope = getCurrentScope();
scope.setPropagationContext({ traceId: generateTraceId(), sampleRand: Math.random() });
// We reset this to ensure we do not have lingering incorrect data here
// places that call this hook may set this where appropriate - else, the URL at span sending time is used
scope.setSDKProcessingMetadata({
normalizedRequest: undefined,
});
_createRouteSpan(client, {
op: 'navigation',
...startSpanOptions,
});
});
client.on('startPageLoadSpan', (startSpanOptions, traceOptions = {}) => {
if (getClient() !== client) {
return;
}
maybeEndActiveSpan();
const sentryTrace = traceOptions.sentryTrace || getMetaContent('sentry-trace');
const baggage = traceOptions.baggage || getMetaContent('baggage');
const propagationContext = propagationContextFromHeaders(sentryTrace, baggage);
const scope = getCurrentScope();
scope.setPropagationContext(propagationContext);
// We store the normalized request data on the scope, so we get the request data at time of span creation
// otherwise, the URL etc. may already be of the following navigation, and we'd report the wrong URL
scope.setSDKProcessingMetadata({
normalizedRequest: getHttpRequestData(),
});
_createRouteSpan(client, {
op: 'pageload',
...startSpanOptions,
});
});
},
afterAllSetup(client) {
let startingUrl: string | undefined = getLocationHref();
if (linkPreviousTrace !== 'off') {
linkTraces(client, { linkPreviousTrace, consistentTraceSampling });
}
if (WINDOW.location) {
if (instrumentPageLoad) {
const origin = browserPerformanceTimeOrigin();
startBrowserTracingPageLoadSpan(client, {
name: WINDOW.location.pathname,
// pageload should always start at timeOrigin (and needs to be in s, not ms)
startTime: origin ? origin / 1000 : undefined,
attributes: {
[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url',
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.pageload.browser',
},
});
}
if (instrumentNavigation) {
addHistoryInstrumentationHandler(({ to, from }) => {
/**
* This early return is there to account for some cases where a navigation transaction starts right after
* long-running pageload. We make sure that if `from` is undefined and a valid `startingURL` exists, we don't
* create an uneccessary navigation transaction.
*
* This was hard to duplicate, but this behavior stopped as soon as this fix was applied. This issue might also
* only be caused in certain development environments where the usage of a hot module reloader is causing
* errors.
*/
if (from === undefined && startingUrl?.indexOf(to) !== -1) {
startingUrl = undefined;
return;
}
startingUrl = undefined;
const parsed = parseStringToURLObject(to);
const activeSpan = getActiveIdleSpan(client);
const navigationIsRedirect =
activeSpan && detectRedirects && isRedirect(activeSpan, lastInteractionTimestamp);
startBrowserTracingNavigationSpan(
client,
{
name: parsed?.pathname || WINDOW.location.pathname,
attributes: {
[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url',
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.browser',
},
},
{ url: to, isRedirect: navigationIsRedirect },
);
});
}
}
if (markBackgroundSpan) {
registerBackgroundTabDetection();
}
if (enableInteractions) {
registerInteractionListener(client, idleTimeout, finalTimeout, childSpanTimeout, latestRoute);
}
if (enableInp) {
registerInpInteractionListener();
}
instrumentOutgoingRequests(client, {
traceFetch,
traceXHR,
trackFetchStreamPerformance,
tracePropagationTargets: client.getOptions().tracePropagationTargets,
shouldCreateSpanForRequest,
enableHTTPTimings,
onRequestSpanStart,
});
},
};
}) satisfies IntegrationFn;
/**
* Manually start a page load span.
* This will only do something if a browser tracing integration integration has been setup.
*
* If you provide a custom `traceOptions` object, it will be used to continue the trace
* instead of the default behavior, which is to look it up on the <meta> tags.
*/
export function startBrowserTracingPageLoadSpan(
client: Client,
spanOptions: StartSpanOptions,
traceOptions?: { sentryTrace?: string | undefined; baggage?: string | undefined },
): Span | undefined {
client.emit('startPageLoadSpan', spanOptions, traceOptions);
getCurrentScope().setTransactionName(spanOptions.name);
return getActiveIdleSpan(client);
}
/**
* Manually start a navigation span.
* This will only do something if a browser tracing integration has been setup.
*/
export function startBrowserTracingNavigationSpan(
client: Client,
spanOptions: StartSpanOptions,
options?: { url?: string; isRedirect?: boolean },
): Span | undefined {
const { url, isRedirect } = options || {};
client.emit('startNavigationSpan', spanOptions, { isRedirect });
const scope = getCurrentScope();
scope.setTransactionName(spanOptions.name);
// We store the normalized request data on the scope, so we get the request data at time of span creation
// otherwise, the URL etc. may already be of the following navigation, and we'd report the wrong URL
if (url && !isRedirect) {
scope.setSDKProcessingMetadata({
normalizedRequest: {
...getHttpRequestData(),
url,
},
});
}
return getActiveIdleSpan(client);
}
/** Returns the value of a meta tag */
export function getMetaContent(metaName: string): string | undefined {
/**
* This is just a small wrapper that makes `document` optional.
* We want to be extra-safe and always check that this exists, to ensure weird environments do not blow up.
*/
const optionalWindowDocument = WINDOW.document as (typeof WINDOW)['document'] | undefined;
const metaTag = optionalWindowDocument?.querySelector(`meta[name=${metaName}]`);
return metaTag?.getAttribute('content') || undefined;
}
/** Start listener for interaction transactions */
function registerInteractionListener(
client: Client,
idleTimeout: BrowserTracingOptions['idleTimeout'],
finalTimeout: BrowserTracingOptions['finalTimeout'],
childSpanTimeout: BrowserTracingOptions['childSpanTimeout'],
latestRoute: RouteInfo,
): void {
/**
* This is just a small wrapper that makes `document` optional.
* We want to be extra-safe and always check that this exists, to ensure weird environments do not blow up.
*/
const optionalWindowDocument = WINDOW.document as (typeof WINDOW)['document'] | undefined;
let inflightInteractionSpan: Span | undefined;
const registerInteractionTransaction = (): void => {
const op = 'ui.action.click';
const activeIdleSpan = getActiveIdleSpan(client);
if (activeIdleSpan) {
const currentRootSpanOp = spanToJSON(activeIdleSpan).op;
if (['navigation', 'pageload'].includes(currentRootSpanOp as string)) {
DEBUG_BUILD &&
logger.warn(`[Tracing] Did not create ${op} span because a pageload or navigation span is in progress.`);
return undefined;
}
}
if (inflightInteractionSpan) {
inflightInteractionSpan.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_IDLE_SPAN_FINISH_REASON, 'interactionInterrupted');
inflightInteractionSpan.end();
inflightInteractionSpan = undefined;
}
if (!latestRoute.name) {
DEBUG_BUILD && logger.warn(`[Tracing] Did not create ${op} transaction because _latestRouteName is missing.`);
return undefined;
}
inflightInteractionSpan = startIdleSpan(
{
name: latestRoute.name,
op,
attributes: {
[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: latestRoute.source || 'url',
},
},
{
idleTimeout,
finalTimeout,
childSpanTimeout,
},
);
};
if (optionalWindowDocument) {
addEventListener('click', registerInteractionTransaction, { capture: true });
}
}
// We store the active idle span on the client object, so we can access it from exported functions
const ACTIVE_IDLE_SPAN_PROPERTY = '_sentry_idleSpan';
function getActiveIdleSpan(client: Client): Span | undefined {
return (client as { [ACTIVE_IDLE_SPAN_PROPERTY]?: Span })[ACTIVE_IDLE_SPAN_PROPERTY];
}
function setActiveIdleSpan(client: Client, span: Span | undefined): void {
addNonEnumerableProperty(client, ACTIVE_IDLE_SPAN_PROPERTY, span);
}
// The max. time in seconds between two pageload/navigation spans that makes us consider the second one a redirect
const REDIRECT_THRESHOLD = 0.3;
function isRedirect(activeSpan: Span, lastInteractionTimestamp: number | undefined): boolean {
const spanData = spanToJSON(activeSpan);
const now = dateTimestampInSeconds();
// More than 300ms since last navigation/pageload span?
// --> never consider this a redirect
const startTimestamp = spanData.start_timestamp;
if (now - startTimestamp > REDIRECT_THRESHOLD) {
return false;
}
// A click happened in the last 300ms?
// --> never consider this a redirect
if (lastInteractionTimestamp && now - lastInteractionTimestamp <= REDIRECT_THRESHOLD) {
return false;

Fix in CursorFix in Web


Bug: Browser Tracing Integration Event Listener Leak

The browserTracingIntegration introduces a memory leak by adding global click and keydown event listeners for redirect detection without ever removing them. This causes listeners to accumulate when the integration is reinitialized or multiple instances are created, such as in SPAs, hot module reloading, or test environments. A cleanup mechanism is required to prevent this accumulation.

packages/browser/src/tracing/browserTracingIntegration.ts#L467-L475

if (detectRedirects && optionalWindowDocument) {
const interactionHandler = (): void => {
lastInteractionTimestamp = timestampInSeconds();
};
addEventListener('click', interactionHandler, { capture: true });
addEventListener('keydown', interactionHandler, { capture: true, passive: true });
}

Fix in CursorFix in Web


Was this report helpful? Give feedback by reacting with 👍 or 👎

@mydea mydea merged commit 3e5eac5 into develop Jul 10, 2025
169 checks passed
@mydea mydea deleted the fn/detect-pageload-redirects branch July 10, 2025 08:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Distinguish redirects from user-initiated nagivations
5 participants